1. 并发控制和锁
在多处理器的时代,程序设计中经常采用多线程以充分利用处理器的性能。在多线程环境下,由于存在共享变量、共享资源等情况,因此有时候需要对多线程的并发访问进行控制。
同很多并发控制的问题类似(例如数据库的并发控制),程序中的并发控制也会使用到例如加悲观锁、乐观锁、多版本视图等技术来完成并发控制(或者称为多线程同步)。因此谈到并发控制,基本上会涉及到锁的概念,而涉及到锁的问题也基本是属于并发控制问题的范畴。
2. Java中的锁
Java中涉及到很多锁的概念,而涉及到的使用层次也不同,因此这里做一个简单的总结。
内置锁/隐式锁
Java的每一个对象都有一个
monitor
,且这个monitor
每一次仅能被一个线程所拥有,这就是内置锁或者叫隐式锁。内置锁的获取、释放通常是如下的范式写的:1
2
3synchronized(obj) {
//当线程获取到obj的内置锁--monitor时,线程会进入到此代码块
}释放内置锁:
1
obj.wait();//当前线程放弃obj对象上的内置锁
或者退出
synchronized
代码块,也会自动释放获取的内置锁。显式锁
顾名思义,显式锁是显式定义的锁。例如并发工具包的
Lock
接口下的一些实现类。内置锁在
Java
的synchronized
关键字的配合下使用起来十分的简单,但是简答的预定义的东西往往缺乏灵活性,因此为了补充内置锁,显示锁提供了一些额外的特性例如:可轮询、可超时、可中断锁等。这些特性在实际的编程中提供着很大的灵活性。Lock
类的实现类常见的主要是ReentrantLock
类。该类提供了几个重要的方法:
lock()
语义同synchronized
tryLock()
提供了可超时的特性,在某些情况下可以通过该特性避免死锁的发生lockInterruptibly() throws InterruptedException
在获取锁失败被阻塞的时候可被中断,而采用synchronized
获取内置锁的时候,无法被中断
可重入锁(Reentrant Lock)
可重入锁指的是已经获取了某个锁的线程去尝试再一次该锁的时候,是可以直接获取到的,而不会阻塞。
可重入锁避免了如下的死锁情况的产生:
1
2
3
4
5
6synchronized void get() {
set();
}
synchronized void set() {
}如果锁不可重入,那么当线程A获取到了“保护”get方法的锁时,那么再进入set方法的时候,会无限期阻塞。而此时,除了线程A,没有任何线程拥有该锁,因此线程A相相等于握着锁去等锁,首尾相连形成死锁了。
读写锁(Read Write Lock)
通常的锁都为互斥锁,大多数被共享的变量都是由这种互斥锁保护。一个时刻只能有一个线程在访问该变量。这个在该变量读多写少的情况下显然效率不高。因为读读不需要并发控制,而读写、 写写才需要并发控制。那么显然应该同数据库的并发控制加锁的策略一样,应该提供两种锁,一个是共享锁(读锁)、另一个是互斥锁(写锁),当读取变量的时候,主需要获取共享锁,而写变量的时候才去获取互斥锁。
Java
并发包中提供了常用的ReentrantReadWriteLock
锁,该锁提供了读锁、写锁、以及锁降级等特性。readLock()
返回该读写锁对应的读锁writeLock()
返回该读写锁对应的写锁
当线程获取写锁的时候,如果该读写锁的读锁、写锁被其他线程占有,则该线程获取锁失败;
当线程获取读锁的时候,如果没有线程持有写锁,则获取读锁成功;否则,获取读锁失败;
读写所允许锁降级:当一个线程持有写锁的时候,可以直接降级为读锁,而不支持锁升级,因为锁升级会可能会引发死锁(当两个持有读锁的线程,同时进行锁升级,那么这两个线程都不会释放自己的读锁,从而发生死锁)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16private ReadWriteLock lock = new ReentrantReadWriteLock(true);
private Lock r = lock.readLock(), w = lock.writeLock();
...
w.lock();
try {
sb.append(append); //降级为read lock
r.lock();
} finally {
w.unlock();//still hold read lock
}
try {
...
} finally {
r.unlock();
}
...
偏向锁(Biased Lock)
偏向锁是
JDK1.6
引入的一项锁优化,指的是偏向锁会偏向第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要同步。在某些情况下,锁不存在多线程竞争的情况,而总是由同一线程在获取、释放、获取、释放。因此,引入了偏向锁,让此种情况下的锁获取的代价变小,偏向锁可以提高带有同步但无竞争的程序性能。1
2
3
4
5
6
7
8
9
10
11
12public class BiasLockDemo {
public static void main(String[] args) {
// TODO Auto-generated method stub
long t = System.currentTimeMillis();
List<Integer> list = new Vector<>();//选择Vector是由于其add方法是synchronized修饰的;
for (int i = 0; i < 1000_0000; i++) {
list.add(i);
}
System.out.println("cost: " + (System.currentTimeMillis() - t) + "ms");
}
}-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
开启偏向锁后,运行时间:1
cost: 340ms
-XX:-UseBiasedLocking
禁用偏向锁后,运行时间:1
cost: 519ms
公平锁/非公平锁
公平锁是指多个线程在等待同一个锁时,必须按照申请锁的先后顺序来获得锁。
非公平锁是指多个线程在等待同一个锁时,是按按照不确定的顺序来选择某一个线程获取锁。
通常来讲,公平锁的性能低于非公平锁,但是公平锁可以解决线程饥饿的问题。
Java
中可以使用new ReentrantLock(true)
构造得到公平锁,而synchronized
则提供的内置锁是非公平的。ps:
Java
中提供的显式锁一般都提供Fair和Non-Fair模式,但是即便是公平模式也会提供一些允许插队(barging)
的方法允许线程先于等待在前面的线程得到锁。悲观锁/乐观锁
悲观锁:主要的并发控制策略之一,假设冲突总是发生,如果不采取同步措施,例如对共享的变量或者资源加锁,那么肯定会出现问题,类似于事前预防。因此无论共享的数据无论是是否出现竞争冲突,都会对它进行正确的同步。
乐观锁:和悲观锁不一样,乐观并发控制策略先进行操作,如果操作的数据没出现竞争,那么操作成功;如果操作的数据出现竞争,那么再进行一些后续的弥补操作(常见的就是不断的重试、或者重试数次返回失败信息),类似事后弥补,实现乐观并发控制策略有多种常见的方式:
- CAS
- 时间戳
- 版本号
存在即合理,悲观锁和乐观锁都有其应用的场景,当数据争用、冲突发生频繁的场景,悲观锁较适合;而数据争用、冲突不频繁的场景,乐观锁则更适合。
自旋锁(Spinning Lock)
互斥同步的时候,当线程获取锁失败的时候,通常会进入阻塞状态,
java
线程和操作系统线程是一一对应的,挂起和恢复线程操作需要由用户态转入核心态完成,这些操作耗时、耗资源。但是某些情况下,某一个线程只会将锁独占很短时间,或者是说很快 便完成了同步代码块的执行,因此其它线程为了这点时间选择将自己挂起、恢复十分没有必要。因此,特别是在多处理环境下,可以让后面请求独占锁失败的线程,进行自旋(忙循环)一会儿,而不是阻塞挂起线程。自旋锁的引入是为了解决锁被独占的时间很短的情况下,避免线程被挂起-恢复带来的
overhead
,因此当锁独占的时间本来就很长的,这种锁便没有存在的意义了。JVM
中可以通过参数:-XX:+UseSpinning
开启自旋锁功能;JDK1.6
默认是开启的。-XX:PreBlockSpin
配置每次自旋的次数,默认是10次;
3. 死锁和活锁
3.1 死锁
并发中问题中的死锁最经典莫过于哲学家就餐问题,死锁常常发生在系统高负载环境下,多线程竞争某一共享数据的情况下。当线程A持有锁L的时候同时,线程B持有锁M并尝试获得L,那么这两个线程将永远等待下去。这种情况就是最简单的死锁形式,多个线程由于存在环路的依赖关系而永远的等待下去。
死锁发生最常见的的根本原因就是:多个线程存在环路的依赖关系。
比如A
等待B,B等待C,C等待D, …, Z等待A,则A间接的等待A,形成环路,发生死锁。
环路的产生具体有如下几种情况:
- 锁顺序死锁:加锁的顺序不一致导致的死锁;
- 动态的锁顺序死锁:方法内部加锁顺序是一致的,但是由于锁被参数化了,因此调用该方法时,锁的顺序取决于方法调用者传来的参数,因此也会动态的产生锁顺序死锁。
- 协作对象之间发生的死锁
- 资源死锁 例如:线程A持有数据库连接D1并等待D2,而线程B持有数据库连接D2,等待D1则A、B之间出现死锁
解决死锁问题通常有两个角度来解决,死锁避免和死锁解除,一个属于事前预防,另一个是事后弥补;
数据库系统中,为避免死锁,有一个著名的两阶段加锁协议,同时,事务管理器可以通过环路判断死锁的存在,并取消一个代价小的事务以达到死锁的解除。
Java
没有数据库事务管理器那么强大,Java
中也有一些方法可以避免死锁,但是当死锁发生的时候,除了重启应用别无他法。
Java
中的死锁避免:
- 加锁顺序保持相同(synchronized提供的内置锁只能通过此种方式来避免死锁的发生)
- 采用可轮询的、可超时的锁(显式锁Lock提供
tryLock(long timeout)
轮询和超时的特性,因此不会无限的等待下去,当超时的时候,程序可以简单的重试,或者放弃获取该锁,释放已有的锁。同时这种方式通过引入随机因素也可以有限的解决活锁的问题)
3.2 活锁
死锁是形成死锁的线程全部处于无限等待状态,而活锁则是线程不断的重复执行相同的操作,而且总是失败。就相当于线程在执行一个循环的操作序列,周而复始,无穷无尽,导致系统的状态整体停滞不前。
最形象的例子便是:
两个过于礼貌的人甲乙,相向走在一个狭窄的巷子里面,甲和乙同时让对方先走,然后甲乙同时准备接受对方的谦让自己先走,然后两人又同时让对方先走…,如此循环往复,两人没有等待,始终处于活动状态,但是两人始终都无法通过巷子。
同样的类似活锁的例子就是,以太网的共享介质传输信息时,也会出现活锁的问题,以太网技术采用了一种叫做载波多路访问-冲突检测(CSMA-CD)的技术,该技术引入了一些随机因素来避免活锁。
同样的,解决活锁的问题,可以在重试机制中以引入随机性,这样可以有效的避免活锁问题。
4.reference
[1]. Java并发编程实践
[2]. 深入理解JVM虚拟机